﻿--[[
    Author: Valana <Team BATTLE> of Mug'thol Horde-US
]]

local _addonName, _addonTitle =  GetAddOnInfo("TB_Assimilator")
local TbAssimilator = LibStub("AceAddon-3.0"):NewAddon(_addonName, "AceConsole-3.0", "AceEvent-3.0", "AceHook-3.0", "AceTimer-3.0", "LibWho-2.0")
local ConfigDialog = LibStub("AceConfigDialog-3.0")
local Deformat = LibStub("LibDeformat-3.0")
local L = LibStub("AceLocale-3.0"):GetLocale(_addonName, true)
local TbCore = LibStub("AceAddon-3.0"):GetAddon("TB_Core")
local R = LibStub("LibBabble-Race-3.0"):GetLookupTable()

-- Player information
local _player = UnitName("player")
local _faction, _localizedFaction = UnitFactionGroup("player")

-- Constants.
local _maxLevel = MAX_PLAYER_LEVEL_TABLE[#MAX_PLAYER_LEVEL_TABLE]
local _day = 60 * 60 * 24
local _halfDay = _day / 2
local _inactiveDays = 119 * _day
local _purgeThreshold = 10 * _day
local _whisperWindowMaxLength = 45
local _inviteDelay = 1
local _regExCharsPattern = "([%+%-%*%.%?%(%)%[%]%%])"
local _sPattern = format("(%s)", TbCore:GetCaseInsensitivePattern("s"))
local _drunkenSPattern = TbCore:GetCaseInsensitivePattern("s") .. "h?"
local _pitchPatternLow = "^" .. gsub(gsub(gsub(gsub(L["DEFAULT_SALES_PITCH_LOW"], _regExCharsPattern, "%%%1"), "%%%%d", "%%d+"), "%%%%s", ".-"), _sPattern, _drunkenSPattern)
local _pitchPattern = "^" .. gsub(gsub(gsub(gsub(L["DEFAULT_SALES_PITCH"], _regExCharsPattern, "%%%1"), "%%%%d", "%%d+"), "%%%%s", ".-"), _sPattern, _drunkenSPattern)
local _applicantPattern = "^" .. gsub(gsub(gsub(L["ACCEPTED_APPLICATION"], _regExCharsPattern, "%%%1"), "%%%%s", ".-"), _sPattern, _drunkenSPattern)
local _pitchPatternCustom
local _spacePattern = "(%[?)(^?)( )(%]?)"
local function _fixSpacePattern(open, caret, space, close) return ((open and #open > 0 and open) or "[") .. ((caret and #caret > 0 and caret) or "") .. "%s%p" .. ((close and #close > 0 and close) or "]") end
local _inviteRequestStartPattern1 = "^[%s%p]*" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_START1"]), _spacePattern, _fixSpacePattern)
local _inviteRequestStartPattern2 = "^[%s%p]*" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_START2"]), _spacePattern, _fixSpacePattern)
local _inviteRequestStartPattern3 = "^[%s%p]*" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_START3"]), _spacePattern, _fixSpacePattern)
local _inviteRequestStartPattern4 = "^[%s%p]*" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_START4"]), _spacePattern, _fixSpacePattern)
local _inviteRequestStartPattern5 = "^[%s%p]*" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_START5"]), _spacePattern, _fixSpacePattern)
local _inviteRequestStartPattern6 = "^[%s%p]*" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_START6"]), _spacePattern, _fixSpacePattern)
local _inviteRequestAnywherePattern1 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_ANYWHERE1"]), _spacePattern, _fixSpacePattern)
local _inviteRequestAnywherePattern2 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_ANYWHERE2"]), _spacePattern, _fixSpacePattern)
local _inviteRequestAnywherePattern3 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_ANYWHERE3"]), _spacePattern, _fixSpacePattern)
local _inviteRequestOtherSubjectPattern1 = "([^%s%p]+)[%s%p]+" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_OTHER_SUBJECT1"]), _spacePattern, _fixSpacePattern)
local _inviteRequestOtherSubjectPattern2 = "([^%s%p]+)[%s%p]+" .. gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_OTHER_SUBJECT2"]), _spacePattern, _fixSpacePattern)
local _inviteRequestOtherTargetPattern1 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_OTHER_TARGET1"]), _spacePattern, _fixSpacePattern) .. "[%s%p]+([^%s%p]+)"
local _inviteRequestOtherTargetPattern2 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_OTHER_TARGET2"]), _spacePattern, _fixSpacePattern) .. "[%s%p]+([^%s%p]+)"
local _inviteRequestOtherTargetPattern3 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_OTHER_TARGET3"]), _spacePattern, _fixSpacePattern) .. "[%s%p]+([^%s%p]+)"
local _inviteRequestOtherTargetPattern4 = gsub(TbCore:GetCaseInsensitivePattern(L["GUILD_INVITATION_REQUEST_OTHER_TARGET4"]), _spacePattern, _fixSpacePattern) .. "[%s%p]+([^%s%p]+)"
local _iPattern = "^" .. L["I"] .. "$"
local _mePattern = "^" .. L["ME"] .. "$"
local _wePattern = "^" .. L["WE"] .. "$"
local _usPattern = "^" .. L["US"] .. "$"
local _perks =
{
	 [1] = { leveling = { 78631 },               maxLevel = { 78631 } },				-- Fast Track Rank 1													Fast Track Rank 1.
	 [2] = { leveling = { 78631 },               maxLevel = { 78631 } },				-- Fast Track Rank 1													Fast Track Rank 1.
	 [3] = { leveling = { 78631, 78633 },        maxLevel = { 78633 } },				-- Fast Track Rank 1, Mount Up.											Mount Up.
	 [4] = { leveling = { 78631, 78633, 78634 }, maxLevel = { 78633, 78634 } },			-- Fast Track Rank 1, Mount Up, Mr. Popularity Rank 1					Mount Up, Mr. Popularity Rank 1.
	 [5] = { leveling = { 78631, 78633, 78634 }, maxLevel = { 78633, 78634 } },			-- Fast Track Rank 1, Mount Up, Mr. Popularity Rank 1					Mount Up, Mr. Popularity Rank 1.
	 [6] = { leveling = { 78632, 78633, 78634 }, maxLevel = { 78633, 78634 } },			-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 1					Mount Up, Mr. Popularity Rank 1.
	 [7] = { leveling = { 78632, 78633, 78634 }, maxLevel = { 78633, 78634, 83942 } },	-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 1					Mount Up, Mr. Popularity Rank 1, Reinforce Rank 1.
	 [8] = { leveling = { 78632, 78633, 78634 }, maxLevel = { 83944, 78634, 83942 } },	-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 1					Hasty Hearth, Mr. Popularity Rank 1, Reinforce Rank 1.
	 [9] = { leveling = { 78632, 78633, 78634 }, maxLevel = { 83944, 78634, 83943 } },	-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 1					Hasty Hearth, Mr. Popularity Rank 1, Reinforce Rank 2.
	[10] = { leveling = { 78632, 78633, 78634 }, maxLevel = { 83944, 78634, 83943 } },	-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 1					Hasty Hearth, Mr. Popularity Rank 1, Reinforce Rank 2.
	[11] = { leveling = { 78632, 78633, 78634 }, maxLevel = { 83944, 78634, 83943 } },	-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 1					Hasty Hearth, Mr. Popularity Rank 1, Reinforce Rank 2.
	[12] = { leveling = { 78632, 78633, 78635 }, maxLevel = { 83944, 78635, 83943 } },	-- Fast Track Rank 2, Mount Up, Mr. Popularity Rank 2					Hasty Hearth, Mr. Popularity Rank 2, Reinforce Rank 2.
	[13] = { leveling = { 78632, 83959, 78635 }, maxLevel = { 83959, 78635, 83943 } },	-- Fast Track Rank 2, Honorable Mention Rank 1, Mr. Popularity Rank 2	Honorable Mention Rank 1, Mr. Popularity Rank 2, Reinforce Rank 2.
	[14] = { leveling = { 78632, 83959, 78635 }, maxLevel = { 83959, 78635, 83943 } },	-- Fast Track Rank 2, Honorable Mention Rank 1, Mr. Popularity Rank 2	Honorable Mention Rank 1, Mr. Popularity Rank 2, Reinforce Rank 2.
	[15] = { leveling = { 78632, 83959, 78635 }, maxLevel = { 83959, 78635, 83943 } },	-- Fast Track Rank 2, Honorable Mention Rank 1, Mr. Popularity Rank 2	Honorable Mention Rank 1, Mr. Popularity Rank 2, Reinforce Rank 2.
	[16] = { leveling = { 78632, 83959, 78635 }, maxLevel = { 83959, 78635, 83943 } },	-- Fast Track Rank 2, Honorable Mention Rank 1, Mr. Popularity Rank 2	Honorable Mention Rank 1, Mr. Popularity Rank 2, Reinforce Rank 2.
	[17] = { leveling = { 78632, 83959, 78635 }, maxLevel = { 83959, 78635, 83943 } },	-- Fast Track Rank 2, Honorable Mention Rank 1, Mr. Popularity Rank 2	Honorable Mention Rank 1, Mr. Popularity Rank 2, Reinforce Rank 2.
	[18] = { leveling = { 78632, 83959, 78635 }, maxLevel = { 83959, 78635, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 1, Mr. Popularity Rank 2	Honorable Mention Rank 1, Mr. Popularity Rank 2, For Great Justice.
	[19] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 78635, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Mr. Popularity Rank 2, For Great Justice.
	[20] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 78635, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Mr. Popularity Rank 2, For Great Justice.
	[21] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 83967, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Have Group Will Travel, For Great Justice.
	[22] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 83967, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Have Group Will Travel, For Great Justice.
	[23] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 83967, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Have Group Will Travel, For Great Justice.
	[24] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 83967, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Have Group Will Travel, For Great Justice.
	[25] = { leveling = { 78632, 83960, 78635 }, maxLevel = { 83960, 83968, 83953 } },	-- Fast Track Rank 2, Honorable Mention Rank 2, Mr. Popularity Rank 2	Honorable Mention Rank 2, Mass Resurrection, For Great Justice.
}
local _catchPhrases =
{
	L["CATCH_PHRASE1"],
	L["CATCH_PHRASE2"],
	L["CATCH_PHRASE3"],
	L["CATCH_PHRASE4"],
	L["CATCH_PHRASE5"],
	L["CATCH_PHRASE6"],
	L["CATCH_PHRASE7"],
	L["CATCH_PHRASE8"],
	L["CATCH_PHRASE9"],
	L["CATCH_PHRASE10"],
	L["CATCH_PHRASE11"],
    format(L["CATCH_PHRASE12"], GetRealmName()),
    format(L["CATCH_PHRASE13"], GetRealmName(), _localizedFaction),
	L["CATCH_PHRASE14"],
	L["CATCH_PHRASE15"],
	L["CATCH_PHRASE16"],
	L["CATCH_PHRASE17"],
	L["CATCH_PHRASE18"],
}

-- Periodic /who.
local _whoQueriesLow =
{
    "1-5",
    "6-15",
    "16-20",
    "21-25",
    "26-30",
    "31-35",
    "36-40",
    "41-45",
	"46-50",
	"51-55",
 	format("55-80 c-%q", LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"]),
}
local _whoQueriesHigh =
{
	"56-60",
    "61-65",
	"66-68",
	"69-70",
    "71-72",
	"73-75",
	"76-78",
    "79-80",
    "81-82",
    "83",
    "84",
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["DRUID"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["HUNTER"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["MAGE"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["PALADIN"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["PRIEST"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["ROGUE"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["SHAMAN"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["WARLOCK"]),
    format("85 c-%q", LOCALIZED_CLASS_NAMES_MALE["WARRIOR"]),
}
local _whoQueriesFine = { }	-- Populate this one based on faction.

-- Temporary flags and variables.
local _db
local _whisperQueue
local _welcomeTimer
local _welcomeNames = { }
local _chatTracker = { }
local _isProcessingGuildRoster
local _numGroupMembers = 0
local _numGuildMates = 0
local _numIgnores = 0
local _numGuildApplicants = 0

-- WhoLib options.
local _whoOptions =
{
    queue = TbAssimilator.WHOLIB_QUEUE_QUIET,
    timeout = 0,
    callback = "OnWhoResults",
    handler = TbAssimilator,
    flags = 0,
}


function TbAssimilator:OnInitialize()
	-- Create our default settings.
    _db = LibStub("AceDB-3.0"):New(_addonName, { factionrealm = { }, }, true)
    _db:SetProfile("Default")

	-- We don't care about errors while cleaning up other guilds' data pertaining.
	pcall(
		function()
			local t = time()
			for tableName, storage in pairs(_db.factionrealm) do
				-- The queried table is stored at the same hierarchical level as the guild tables.
				-- TODO: Remove this check in 4.3.
				if tableName ~= "queried" then
					-- Fill in any missing last login date.
					-- TODO: Remove the lastLogIn assignment in 4.3.
					storage.lastLogIn = storage.lastLogIn or t
					local sinceLastLogIn = t - storage.lastLogIn

					-- Clear out player data for guilds we have not logged in to for 4 months.
					-- TODO: change the new array assignment to a wipe() in 4.3 and remove the first check.
					if not storage.players or sinceLastLogIn > _inactiveDays then
						storage.players = { }
					end

					-- Clear out query data for guilds we have not logged in to for 12 hours.
					-- TODO: change the new array assignment to a wipe() in 4.3 and remove the first check.
					if not storage.queried or sinceLastLogIn > _halfDay then
						storage.queried = { }
					end

					-- Clear out the whisper queue for guilds we have not logged in to for 10 minutes. These data become stale very quickly so we really only want to preserve them in-between quick
					-- relogs.
					if sinceLastLogIn > 600 then
						storage.whisperQueue = nil
					end
				end
			end
		end)

    -- Create the options menus.
    LibStub("AceConfig-3.0"):RegisterOptionsTable(_addonName,
        {
            type = "group",
            name = _addonTitle,
            desc = GetAddOnMetadata(_addonName, "Notes"),
            handler = TbAssimilator,
            args =
            {
                -- subHeader =
                -- {
                    -- order = 0,
                    -- type = "description",
                    -- name = L["MENU_SUBHEADER"],
                -- },
				stats =
				{
					order = 1,
					type = "description",
					name = function() return table.concat(TbAssimilator:GetStatsMessage(), " ") end,
				},
				infestation =
				{
					order = 10,
					type = "group",
					inline = true,
					name = L["MENU_INFESTATION"],
					disabled = "CannotUseAddOn",
					args =
					{
						subHeader =
						{
                            order = 0,
                            type = "description",
                            name = L["MENU_INFESTATION_SUBHEADER"],
						},
						isEnabled =
						{
							order = 10,
							type = "toggle",
							name = L["MENU_ENABLED"],
							desc = L["MENU_ENABLED_DESCRIPTION"],
							get = "IsEnabled",
							set = "ToggleEnabled",
						},
						useCustomSalesPitch =
						{
							order = 11,
							type = "toggle",
							name = L["MENU_USE_CUSTOM_SALES_PITCH"],
							desc = L["MENU_USE_CUSTOM_SALES_PITCH_DESCRIPTION"],
							get = "IsUsingCustomSalesPitch",
							set = "ToggleUseCustomSalesPitch",
						},
						customSalesPitch =
						{
							order = 20,
							type = "input",
							width = "full",
							name = L["MENU_CUSTOM_SALES_PITCH"],
							desc = L["MENU_CUSTOM_SALES_PITCH_DESCRIPTION"],
							disabled = function() return not TbAssimilator:IsUsingCustomSalesPitch() end,
							get = "GetCustomSalesPitch",
							set = "SetCustomSalesPitch",
						},
						maxMembers =
						{
							order = 30,
							type = "range",
							name = L["MENU_NUM_MEMBERS_TARGET"],
							desc = L["MENU_NUM_MEMBERS_TARGET_DESCRIPTION"],
							width = "full",
							min = 1,
							max = 1000,
							step = 1,
							bigStep = 50,
							get = "GetNumMembersTarget",
							set = "SetNumMembersTarget",
						},
					},
				},
                assimilation =
                {
                    order = 20,
                    type = "group",
                    inline = true,
                    name = L["MENU_ASSIMILATION"],
					disabled = "CannotUseAddOn",
                    args =
                    {
                        subHeader =
                        {
                            order = 0,
                            type = "description",
                            name = L["MENU_ASSIMILATION_SUBHEADER"],
                        },
                        assimilationRank =
                        {
                            order = 10,
                            type = "select",
                            name = L["MENU_ASSIMILATION_RANK"],
                            desc = L["MENU_ASSIMILATION_RANK_DESCRIPTION"],
                            style = "dropdown",
                            values = function() return TbAssimilator:GetGuildRanks() end,
                            get = "GetAssimilationRankZeroBased",
                            set = "SetAssimilationRankZeroBased",
						},
						timeStampLocation =
						{
							order = 11,
							type = "select",
							name = L["MENU_TIME_STAMP_LOCATION"],
							desc = L["MENU_TIME_STAMP_LOCATION_DESCRIPTION"],
                            style = "dropdown",
							values = function() return TbAssimilator:GetTimeStampLocations() end,
                            get = "GetTimeStampLocation",
                            set = "SetTimeStampLocation",
						},
						welcomeZerglings =
						{
							order = 12,
							type = "toggle",
							name = L["MENU_WELCOME_ZERGLINGS"],
							desc = L["MENU_WELCOME_ZERGLINGS_DESCRIPTION"],
							disabled = "CannotGuildTalk",
							get = "IsWelcomingZerglings",
							set = "ToggleWelcomeZerglings",
						},
                        keepNote =
                        {
                            order = 20,
                            type = "input",
                            name = L["MENU_KEEP_NOTE"],
                            desc = L["MENU_KEEP_NOTE_DESCRIPTION"],
                            get = "GetKeepNote",
                            set = "SetKeepNote",
                        },
                        kickNote =
                        {
                            order = 21,
                            type = "input",
                            name = L["MENU_KICK_NOTE"],
                            desc = L["MENU_KICK_NOTE_DESCRIPTION"],
                            get = "GetKickNote",
                            set = "SetKickNote",
                        },
                    },
                },
				blackList =
				{
                    order = 30,
                    type = "group",
                    inline = true,
                    name = L["MENU_BLACKLIST"],
					disabled = "CannotUseAddOn",
                    args =
                    {
                        subHeader =
                        {
                            order = 0,
                            type = "description",
                            name = L["MENU_BLACKLIST_SUBHEADER"],
                        },
						-- spammers =
						-- {
							-- order = 10,
							-- type = "toggle",
							-- name = L["MENU_BLACKLIST_SPAMMERS"],
							-- desc = L["MENU_BLACKLIST_SPAMMERS_DESCRIPTION"],
							-- get = "IsBlackListingSpammers",
							-- set = "ToggleBlackListSpammers",
						-- },
						blackList =
						{
							order = 20,
							type = "input",
							multiline = (GetScreenHeight() >= 768 and 20) or (GetScreenHeight() >= 600 and 8) or true,
							width = "full",
							name = "",
							get = "GetBlackList",
							set = "SetBlackList",
						},
					},
				},
            },
        })

	-- Define the /tba command.
    self:RegisterChatCommand("tba", "SlashCommand")
end

function TbAssimilator:OnEnable(e)
	-- Increase the size of the options dialog if the player's screen resolution would allow it. The default values are 700 and 500.
	-- I made my determinations with a UI scale of .75 so adjust the values to the user.
	local scaleFactor = tonumber(GetCVar("uiScale")) / .75
	ConfigDialog:SetDefaultSize(_addonName, min(1150 * scaleFactor, GetScreenWidth() - 50 * scaleFactor), min(775 * scaleFactor, GetScreenHeight() - 50 * scaleFactor))

    -- Add our options menus to the Blizzard interface menu.
    ConfigDialog:AddToBlizOptions(_addonName)

    -- Check whether we are in a guild.
	if GetGuildInfo("player") then
		self:PLAYER_GUILD_UPDATE("PLAYER_GUILD_UPDATE", "player")
	else
		self:RegisterEvent("PLAYER_GUILD_UPDATE")
	end
end

function TbAssimilator:OnDisable()
    ChatFrame_RemoveMessageEventFilter("CHAT_MSG_WHISPER_INFORM", TbAssimilator.WhisperInformFilter)
	ChatFrame_RemoveMessageEventFilter("CHAT_MSG_IGNORED", TbAssimilator.IgnoredFilter)
	ChatFrame_RemoveMessageEventFilter("CHAT_MSG_AFK", TbAssimilator.AfkDndFilter)
	ChatFrame_RemoveMessageEventFilter("CHAT_MSG_DND", TbAssimilator.AfkDndFilter)
	ChatFrame_RemoveMessageEventFilter("CHAT_MSG_WHISPER", TbAssimilator.WhisperFilter)
	ChatFrame_RemoveMessageEventFilter("CHAT_MSG_SYSTEM", TbAssimilator.SystemFilter)
end

function TbAssimilator:PLAYER_GUILD_UPDATE(e, unit)
    if unit ~= "player" or not GetGuildInfo("player") then return end

	-- It is simpler (with little overhead) to just enable everything no matter what, and not take any action if we are not supposed to run, rather than registering and unregistering
	-- everything dynamically.
	self:UnregisterEvent("PLAYER_GUILD_UPDATE")

	-- It appears that sometimes the guild's level is not immediately available upon login? This is a bit strange, and may only happen on a new character that has not been in the guild
	-- before. Try to update the guild's experience level now to resolve this.
	self:RegisterEvent("GUILD_XP_UPDATE")
	QueryGuildXP()
end

function TbAssimilator:GUILD_XP_UPDATE()
	-- We should now have all the information we need about our guild.
	self:UnregisterEvent("GUILD_XP_UPDATE")

	-- Initialize this guild's data if applicable.
	local guild = GetGuildInfo("player")
	if not _db.factionrealm[guild] then
		_db.factionrealm[guild] =
			{
				queried = { },
                players = { },
				numAssimilated = 0,
				numKicked = 0,
				welcomeZerglings = true,
				customSalesPitch = "",
				numMembersTarget = 925,
				assimilationRankZeroBased = GuildControlGetNumRanks() - 1,
				timeStampLocation = 0,
				keepNote = "",
				kickNote = "",
				blackList = { },
				whisperWindowStart = 0,
				numWhisperWindowWhispered = 0,
				numWhisperWindowConsecutive = 0,
			}

		-- Remind the user that the add-on needs to be enabled.
		self:PrintEmphasis(L["NEW_GUILD_REMINDER"])
	elseif not self:IsEnabled() then
		self:PrintEmphasis(L["ADDON_DISABLED_WARNING"], _addonTitle)
	end

	-- Migrate any old player data.
	-- TODO: Remove in 4.3 (legacy code).
	_db.factionrealm[guild].queried = _db.factionrealm[guild].queried or { }
	_db.factionrealm[guild].players = _db.factionrealm[guild].players or { }
	if _db.factionrealm.players then
		for name, data in pairs(_db.factionrealm.players) do
			self:GetPlayersDb()[name] = data
		end
	end
	_db.factionrealm.players = nil
	_db.factionrealm.queried = nil
	_db.factionrealm[guild].customSalesPitch = _db.factionrealm[guild].customSalesPitch or ""
	_db.factionrealm[guild].timeStampLocation = _db.factionrealm[guild].timeStampLocation or 2	-- Anyone running the r56-beta would want to keep using officer note for the first reconciliation.
	_db.factionrealm[guild].blackList = _db.factionrealm[guild].blackList or { }
	-- Convert back from .whisperSucceeded to .whispered.
	for name, data in pairs(self:GetPlayersDb()) do
		if data.whisperSucceeded then
			data.whispered = data.whisperSucceeded
			data.whisperSucceeded = nil
		end
	end
	_db.factionrealm[guild].whisperWindowStart = _db.factionrealm[guild].whisperWindowStart or 0
	_db.factionrealm[guild].numWhisperWindowWhispered = _db.factionrealm[guild].numWhisperWindowWhispered or 0
	_db.factionrealm[guild].numWhisperWindowConsecutive = _db.factionrealm[guild].numWhisperWindowConsecutive or 0
	-- END TODO

	-- Mark the last time we logged in to this guild.
    local t = time()
	_db.factionrealm[guild].lastLogIn = t

    -- Reset the players we queried with /who earlier than 12 hours ago, making them eligible for a new check. This table may have been wiped already during initialization.
    for name, queryTime in pairs(self:GetQueriedDb()) do
        if t - queryTime > _halfDay then
            self:GetQueriedDb()[name] = nil
        end
    end

	-- Prepare the function to purge our player data, but we will only invoke once we have retrieved the roster, since we rely on UnitIsInMyGuild().
	function TbAssimilator:PurgePlayersDb()
		-- Reset the players we attempted to assimilate a long time ago. Also remove orphans, whom we whispered but didn't invite due to the short delay in-between the two (but only after a
		-- little while to avoid spamming them).
		-- Do keep guildmates, since their data contains their assimilation date.
		for name, data in pairs(self:GetPlayersDb()) do
			-- TODO: Remove the type(data) check in 4.3. There seemed to be an error in the data upgrade from r56-beta.
			if type(data) ~= "table" or (not UnitIsInMyGuild(name) and (not data.whispered or t - data.whispered > _purgeThreshold or (not data.invited and t - data.whispered > 600))) then
				self:GetPlayersDb()[name] = nil
			end
		end

		-- Only run this function once.
		function TbAssimilator:PurgePlayersDb() end
	end

	-- Request the guild roster to make sure the player database is purged.
	GuildRoster()

	-- Build the pattern that will detect custom sales pitches.
	self:UpdateCustomSalesPitchPattern()

	-- Add the faction-specific, class-based /who queries.
	if _faction == "Horde" then
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DRUID"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DRUID"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Undead"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Undead"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Undead"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PALADIN"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PALADIN"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Undead"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Undead"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["SHAMAN"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["SHAMAN"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["SHAMAN"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["SHAMAN"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Undead"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Blood Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Goblin"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Orc"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Tauren"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Troll"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Undead"]))
	else
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DRUID"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DRUID"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Gnome"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["DEATHKNIGHT"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["HUNTER"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Gnome"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["MAGE"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PALADIN"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PALADIN"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PALADIN"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Gnome"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["PRIEST"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Gnome"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["ROGUE"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["SHAMAN"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["SHAMAN"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Gnome"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARLOCK"], R["Worgen"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Draenei"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Dwarf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Gnome"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Human"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Night Elf"]))
		tinsert(_whoQueriesFine, format("%d c-%q r-%q", _maxLevel, LOCALIZED_CLASS_NAMES_MALE["WARRIOR"], R["Worgen"]))
	end

	-- Override all guild invitations and removals.
	self:RawHook("GuildInvite", true)
	self:RawHook("GuildUninvite", true)

	-- Override the "Report Player" command.
	self:Hook("ComplainChat", true)

	-- Define the /gkick command.
	self:RegisterChatCommand(L["CMD_GKICK"], function(name) TbAssimilator:GuildUninvite(name) end)

	-- Listen to guild roster changes so we may promote and mark assimilated members.
	self:RegisterEvent("GUILD_ROSTER_UPDATE")

	-- Welcome new recruits to the guild. This will also cause incoming /who data to be processed.
	self:RegisterEvent("CHAT_MSG_SYSTEM")

	-- We don't want to see automated, outgoing sales pitches.
	self:RegisterEvent("CHAT_MSG_WHISPER_INFORM")
	ChatFrame_AddMessageEventFilter("CHAT_MSG_WHISPER_INFORM", TbAssimilator.WhisperInformFilter)

	-- Since we hide automated, outgoing sales pitches, we need to add an explanatory note when an invitee replies.
	ChatFrame_AddMessageEventFilter("CHAT_MSG_WHISPER", TbAssimilator.WhisperFilter)

	-- We don't care if people we invite are ignoring us.
	ChatFrame_AddMessageEventFilter("CHAT_MSG_IGNORED", TbAssimilator.IgnoredFilter)

	-- We don't want to see AFK and DND responses to automated messages.
	ChatFrame_AddMessageEventFilter("CHAT_MSG_AFK", TbAssimilator.AfkDndFilter)
	ChatFrame_AddMessageEventFilter("CHAT_MSG_DND", TbAssimilator.AfkDndFilter)

	-- We also don't want to see system messages announcing automated guild invitations and their outcomes.
	ChatFrame_AddMessageEventFilter("CHAT_MSG_SYSTEM", TbAssimilator.SystemFilter)

	-- The following events provide guild information on other players.
	self:RegisterEvent("UPDATE_MOUSEOVER_UNIT", "ProcessUnit", "mouseover")
	self:RegisterEvent("PLAYER_TARGET_CHANGED", "ProcessUnit", "target")
	self:RegisterEvent("WHO_LIST_UPDATE")

	-- The following events will effect /who queries.
	self:RegisterEvent("CHAT_MSG_WHISPER")
	self:RegisterEvent("CHAT_MSG_GUILD")
	self:RegisterEvent("CHAT_MSG_OFFICER", "CHAT_MSG_GUILD")
	self:RegisterEvent("CHAT_MSG_CHANNEL")
	self:RegisterEvent("ZONE_CHANGED_NEW_AREA")

	-- ZONE_CHANGED_NEW_AREA does not fire on login or on UI reload. Produce a /who on the current area (with a delay because the new zone's name is not immediately available upon login).
	self:ScheduleTimer("ZONE_CHANGED_NEW_AREA", 5)

	-- Periodically submit pre-defined /who queries, using five timers running out of phase. In order to favor the lower lower level ranges rather than the fine-grained level 85 queries,
	-- each of the main query lists gets 2 timers, whereas the fine-grained queries only get one.
	-- The /who query is throttled server-side to one per 5 seconds, so query every 15 seconds to leave so room for ad hoc queries.
	self:ScheduleTimer(function() TbAssimilator:ScheduleRepeatingTimer("OnWhoTimer", 75, _whoQueriesLow) end, 15)
	self:ScheduleTimer(function() TbAssimilator:ScheduleRepeatingTimer("OnWhoTimer", 75, _whoQueriesHigh) end, 30)
	self:ScheduleTimer(function() TbAssimilator:ScheduleRepeatingTimer("OnWhoTimer", 75, _whoQueriesLow) end, 45)
	self:ScheduleTimer(function() TbAssimilator:ScheduleRepeatingTimer("OnWhoTimer", 75, _whoQueriesHigh) end, 60)
	self:ScheduleRepeatingTimer("OnWhoTimer", 75, _whoQueriesFine)

	-- Make sure we don't have ignored players in the guild.
	self:IGNORELIST_UPDATE()
	self:RegisterEvent("IGNORELIST_UPDATE")

	-- Print a notification whenever we have new guild recruiting applications.
	self:RegisterEvent("LF_GUILD_RECRUITS_UPDATED")
	self:RegisterEvent("LF_GUILD_RECRUIT_LIST_CHANGED", "LF_GUILD_RECRUITS_UPDATED")
	RequestGuildApplicantsList()


	-- Instantiate a proper whisper queue from our storage array and start our timer that will stagger sales pitches.
	_whisperQueue = TbCore:NewQueue(_db.factionrealm[guild].whisperQueue)
	_db.factionrealm[guild].whisperQueue = _whisperQueue.array
	self:OnWhisperTimer()
end

-- TODO: Remove the recipient == _player clause once Chat Enhancements is back.
function TbAssimilator.WhisperInformFilter(frame, e, message, recipient)
	if recipient == _player then
		return true
	end
	if TbAssimilator:GetPlayersDb()[recipient] and TbAssimilator:GetPlayersDb()[recipient].isForced then return end

    -- Block outgoing sales pitches.
	-- TODO: There were strange circumstances where GetPlayersDb()[sender] was nil even though the pattern matched. Never had that problem in the early versions...
	if strmatch(message, _pitchPattern) or (TbAssimilator:IsUsingCustomSalesPitch() and #_pitchPatternCustom > 1 and strmatch(message, _pitchPatternCustom)) or strmatch(message, _pitchPatternLow) or strmatch(message, _applicantPattern) then
		return true
	end
end

function TbAssimilator:CHAT_MSG_WHISPER_INFORM(e, message, recipient)
	if recipient == _player or not self:GetPlayersDb()[recipient] then return end
	if strmatch(message, _pitchPattern) or (self:IsUsingCustomSalesPitch() and #_pitchPatternCustom > 1 and strmatch(message, _pitchPatternCustom)) or strmatch(message, _pitchPatternLow) or strmatch(message, _applicantPattern) then
		-- Keep track of when we whispered players so we don't bother them again (for a time) if they decline. Only mark this here to work around all chat throttling, both from the server
		-- and ChatThrottleLib.
		self:GetPlayersDb()[recipient].whispered = time()

		-- Schedule a guild invitation in a second to at least give the recipient a moment to notice the message.
		self:ScheduleTimer(function() TbAssimilator:GuildInvite(recipient, not TbAssimilator:GetPlayersDb()[recipient].isForced) end, _inviteDelay)
	end
end

function TbAssimilator.SystemFilter(frame, e, message)
    -- Block system messages pertaining to automated guild invitations and their outcomes.
	local t, name = time(), Deformat(message, ERR_CHAT_PLAYER_NOT_FOUND_S)

	-- Sending a guild invitation to someone who is ignoring us produces a CHAT_MSG_SYSTEM notification rather than CHAT_MSG_IGNORED (which results from whispers).
	name = name or Deformat(message, ERR_GUILD_PLAYER_NOT_FOUND_S)
	name = name or Deformat(message, ERR_IGNORING_YOU_S)
	name = name or Deformat(message, ERR_GUILD_INVITE_S)
	name = name or Deformat(message, ERR_ALREADY_IN_GUILD_S)
	name = name or Deformat(message, ERR_GUILD_DECLINE_S)
	name = name or Deformat(message, ERR_GUILD_DECLINE_AUTO_S)
	name = name or Deformat(message, ERR_ALREADY_INVITED_TO_GUILD_S)
	name = name or Deformat(message, ERR_GUILD_PLAYER_NOT_IN_GUILD_S)
	if name then
		-- We only check name == _player because occasionally, a strange bug causes the inviting player's name to show up as already belonging to a guild (???).
		-- Also whenever we kick players en masse, we tend to get the error that the player doing the kicking is not in our guild (???).
		if name == _player then
			return true
		end

		-- The timeout for guild invitations is a little over 1 minute, after which they are automatically declined.
		-- Note: ERR_CHAT_PLAYER_NOT_FOUND_S means that the CHAT_MSG_WHISPER_INFORM handler never fired, in which case neither .invited nor .whispered was set. Do not set .whispered though.
		if TbAssimilator:GetPlayersDb()[name] and not TbAssimilator:GetPlayersDb()[name].isForced and t - (TbAssimilator:GetPlayersDb()[name].invited or TbAssimilator:GetPlayersDb()[name].whispered or t) < 65 then
			return true
		end

		-- Don't block the system message.
		return
	end

	-- Block guild promotion messages for a few seconds after a Zergling joins.
	local source, target = Deformat(message, ERR_GUILD_PROMOTE_SSS)
	if source == _player and TbAssimilator:GetPlayersDb()[target] and TbAssimilator:GetPlayersDb()[target].joined and t - TbAssimilator:GetPlayersDb()[target].joined < 5 then
		return true
	end

    -- Block guild removal messages while we are processing the guild roster (because we are mass kicking members).
    if not source and not target and _isProcessingGuildRoster then
        target, source = Deformat(message, ERR_GUILD_REMOVE_SS)
		if source == _player then
			return true
		end
    end
end

function TbAssimilator.IgnoredFilter(frame, e, _, sender)
	if not sender or not TbAssimilator:GetPlayersDb()[sender] or TbAssimilator:GetPlayersDb()[sender].isForced then return end

    -- Filters "ignored" errors resulting from whispering someone who is ignoring us.
	local t = time()
	TbAssimilator:GetPlayersDb()[sender].whispered = TbAssimilator:GetPlayersDb()[sender].whispered or t
	if not TbAssimilator:GetPlayersDb()[sender].isForced and t - TbAssimilator:GetPlayersDb()[sender].whispered < 5 then
		return true
	end
end

function TbAssimilator.AfkDndFilter(frame, e, _, sender)
	if not TbAssimilator:GetPlayersDb()[sender] or TbAssimilator:GetPlayersDb()[sender].isForced then return end

    -- Block AFK and DND automated responses received shortly after sending a sales pitch.
	local t = time()
	TbAssimilator:GetPlayersDb()[sender].whispered = TbAssimilator:GetPlayersDb()[sender].whispered or t
	if t - TbAssimilator:GetPlayersDb()[sender].whispered < 5 then
		return true
	end
end

function TbAssimilator.WhisperFilter(frame, e, message, sender, ...)
	if not TbAssimilator:GetPlayersDb()[sender] then return end

    -- Filter out Deadly Boss Mods automated replies for 10 minutes after whispering a player.
	local t = time()
	TbAssimilator:GetPlayersDb()[sender].whispered = TbAssimilator:GetPlayersDb()[sender].whispered or t
	if not TbAssimilator:GetPlayersDb()[sender].isForced and t - TbAssimilator:GetPlayersDb()[sender].whispered < 600 and (strmatch(message, "^<Deadly Bosh?sh? Modsh?> ") or strmatch(message, "^<DBM> "))then
		return true
	end

    -- Append an explanatory note if we whispered that player a sales pitch, with no time restrictions (primarily because if that player is "already in a guild", we've set .whispered to be
	-- several days back). Also if the player has multiple chat windows subscribed to whispers, this callback will be invoked multiple times within the 1 second granularity of the time()
	-- function, once for each chat frame. Add the note to every one of them.
	TbAssimilator:GetPlayersDb()[sender].replied = TbAssimilator:GetPlayersDb()[sender].replied or t
	if TbAssimilator:GetPlayersDb()[sender].replied == t then
		local addendumFormat = L["YOU_TRIED_TO_ASSIMILATE_ME"]
		if UnitIsInMyGuild(sender) then
			addendumFormat = L["YOU_ASSIMILATED_ME"]
		elseif TbAssimilator:IsGuildApplicant(sender) then
			addendumFormat = L["I_APPLIED_FOR_ASSIMILATION"]
		end

		return false, format("%s (%s)", message, format(addendumFormat, GetGuildInfo("player"))), sender, ...
	end
end

function TbAssimilator:OnWhoTimer(whoQueries)
	if not self:ShouldInvite() then return end

	-- Submit a random, pre-defined /who query.
	self:Who(whoQueries[random(1, #whoQueries)], _whoOptions)

	-- Refresh our guild's experience data and applicants list while we're at it. By default, they refresh somewhat unfrequently, which makes it annoying to track the guild's progress.
	QueryGuildXP()
	RequestGuildApplicantsList()
end

function TbAssimilator:ZONE_CHANGED_NEW_AREA()
    -- As of 4.1, we can no longer query against arena zones (not that we'd want to anyway, since we need to focus, and besides players in arenas would likely ignore such whispers).
	if not self:ShouldInvite() then return end

	self:Who(format("z-%q", GetRealZoneText()), _whoOptions)
end

function TbAssimilator:TryHandleSpam(message, sender, line)
	if not self:IsBlackListingSpammers() or sender == _player or TbCore:IsFriend(sender) then return end

	-- Consider a spammer anyone who sends 5 messages within a 4 second window that is not a friend.
	local t = time()
	_chatTracker[sender] = _chatTracker[sender] or { }
	if not _chatTracker[sender].start or t - _chatTracker[sender].start > 4 then
		-- Reset our tracking of this player.
		_chatTracker[sender].start = t
		_chatTracker[sender].numMessages = 1
	else
		-- Increment our counter.
		_chatTracker[sender].numMessages = _chatTracker[sender].numMessages + 1
		if _chatTracker[sender].numMessages > 4 then
			-- Report the player for spamming, blacklisting them in the process. This is not possible for messages sent over guild chat, for whispers from trial accounts (since we needed
			-- to initiate the first whisper), against friends, game masters, etc.
			ComplainChat(sender, message)
			return true
		end
	end
end

function TbAssimilator:ComplainChat(name)
	if type(name) == "string" then
		-- Blacklist any player we report for spamming, even if they already were.
		name = TbCore:Capitalize(self:Trim(name))
		_db.factionrealm[GetGuildInfo("player")].blackList[name] = true

		-- Remove the player from the guild if applicable, then blacklist them.
		if UnitIsInMyGuild(name) then
			self:AddKickNote(name)
			GuildUninvite(name)
		end
	end
end

function TbAssimilator:CHAT_MSG_WHISPER(e, message, sender, language, channel, target, flag, unknown1, channelNumber, channelName, unknown2, line)
	if self:TryHandleSpam(message, sender, line) or not self:ShouldInvite(nil, true) then return end

	if not UnitIsInMyGuild(sender) then
		-- If the sender asks for a guild invitation, wipe their data to force them through the process again. Otherwise, query them again.
		local isRequest = strmatch(message, _inviteRequestStartPattern1)
		isRequest = isRequest or strmatch(message, _inviteRequestStartPattern2)
		isRequest = isRequest or strmatch(message, _inviteRequestStartPattern3)
		isRequest = isRequest or strmatch(message, _inviteRequestStartPattern4)
		isRequest = isRequest or strmatch(message, _inviteRequestStartPattern5)
		isRequest = isRequest or strmatch(message, _inviteRequestStartPattern6)
		isRequest = isRequest or strmatch(message, _inviteRequestAnywherePattern1)
		isRequest = isRequest or strmatch(message, _inviteRequestAnywherePattern2)
		isRequest = isRequest or strmatch(message, _inviteRequestAnywherePattern3)
		if isRequest then
			GuildInvite(sender)
		else
			if self:ShouldInvite(sender, true) and not self:GetQueriedDb()[sender] then
				self:Who(sender, _whoOptions)
			end
		end
	end

	-- Look for a guild invitation request for another player.
	self:CHAT_MSG_GUILD(e, message, sender, language, channel, target, flag, unknown1, channelNumber, channelName, unknown2, line)
end

function TbAssimilator:CHAT_MSG_GUILD(e, message, sender, language, channel, target, flag, unknown1, channelNumber, channelName, unknown2, line)
	-- CHAT_MSG_WHISPER calls this function to look for a guild invitation request for another player, so make sure we don't try to handle a message as spam multiple times.
	if (e ~= "CHAT_MSG_WHISPER" and self:TryHandleSpam(message, sender, line)) or not self:ShouldInvite(nil, true) then return end

	-- Try to fulfill guild invitation requests posted over guild chat automatically.
	local name = strmatch(message, _inviteRequestOtherSubjectPattern1)
    name = name or strmatch(message, _inviteRequestOtherSubjectPattern2)
    name = name or strmatch(message, _inviteRequestOtherTargetPattern1)
    name = name or strmatch(message, _inviteRequestOtherTargetPattern2)
    name = name or strmatch(message, _inviteRequestOtherTargetPattern3)
    name = name or strmatch(message, _inviteRequestOtherTargetPattern4)

	-- Always re-query the subject of a specific request (don't check queried[name]).
	if name and self:ShouldInvite(name, true) and not strmatch (name, _iPattern) and not strmatch(name, _mePattern) and not strmatch(name, _wePattern) and not strmatch(name, _usPattern) then
		-- Clear the query time associated with that player to indicate that this is a manual query.
		self:GetQueriedDb()[name] = nil
		self:Who(name, _whoOptions)
	end
end

function TbAssimilator:CHAT_MSG_CHANNEL(e, message, sender, language, fullChannelName, target, flag, ...)
	-- If a player talks on an open channel, they are guaranteed to be from our own faction (unlike /say and /yell).
	if flag == "GM" or not sender or not self:ShouldInvite(sender) or (self:GetQueriedDb()[sender] and not strmatch(message, TbCore:GetCaseInsensitivePattern(L["LOOKING_FOR_GUILD1"])) and not strmatch(message, TbCore:GetCaseInsensitivePattern(L["LOOKING_FOR_GUILD2"]))) then return end

	-- Prepend with "n-" to indicate that this is an automated query.
	self:Who("n-" .. sender, _whoOptions)
end

function TbAssimilator:PARTY_MEMBERS_CHANGED()
    -- Refresh our data if we have more group members than during the previous check, because this event is extremely noisy. GetNumPartyMembers() == 0 if ungrouped.
	if self:ShouldInvite() then
		local numGroupMembers = GetNumPartyMembers()
		local prefix = "party"
		if UnitInRaid("player") then
			numGroupMembers = GetNumRaidMembers() - 1
			prefix = "raid"
		end

		if numGroupMembers > _numGroupMembers then
			for i = 1, numGroupMembers do
				self:ProcessUnit(prefix .. i)
			end
		end
		_numGroupMembers = numGroupMembers
	else
		_numGroupMembers = 0
	end
end

-- Handles PARTY_MEMBERS_CHANGED, UPDATE_MOUSEOVER_UNIT, PLAYER_TARGET_CHANGED.
function TbAssimilator:ProcessUnit(unit)
    -- Provide defaults for the optional parameters.
    unit = unit or "target"

    -- Only process players from the same faction, and avoid players in our arena match (we can't whisper them). Also, it is possible to target and mouseover party/raid members whose guild
    -- affiliations cannot be determined, eg. because they are out of range. Play it safe and only process the ones who are fairly close. Chances are, they will be moused over or targeted
    -- from up close soon anyway.
    if not UnitIsPlayer(unit) or UnitFactionGroup(unit) ~= _faction or not self:ShouldInvite() or ((UnitInRaid(unit) or UnitInParty(unit)) and not CheckInteractDistance(unit, 1)) then return end

    local name, realm = UnitName(unit)
    self:ScheduleWhisperSalesPitch(name, realm, GetGuildInfo(unit), UnitLevel(unit))
end

function TbAssimilator:OnWhoResults(query, results)
	-- When many who queries are sent in succession, the text of the WhoFrame edit box may become desynchronized with the results being displayed. Try to fix this.
	WhoFrameEditBox:SetText(query)
    for _, result in ipairs(results) do
		self:ScheduleWhisperSalesPitch(result.Name, GetRealmName(), result.Guild, result.Level)
    end
end

function TbAssimilator:WHO_LIST_UPDATE()
	-- Consider anything that doesn't contain a dash (queries against zones or classes or level ranges), or against specific levels, a manual query.
	local query = WhoFrameEditBox:GetText()
	local isManual = not strmatch(query, "%-") and not strmatch(query, "%d")
    for i = 1, GetNumWhoResults() do
        local realm, name, guild, level = "manual", GetWhoInfo(i)
		if not isManual then
			realm = GetRealmName()
		end
		self:ScheduleWhisperSalesPitch(name, realm, guild, level)
    end
end

function TbAssimilator:CHAT_MSG_SYSTEM(e, message)
	-- Allow players who are "already in a guild" or "already invited to a guild" to be invited again in 12 hours. There is a bug whereby guildless players cannot be invited because of
	-- this. Note: the event handler is invoked after the event filter.
	local name = Deformat(message, ERR_ALREADY_IN_GUILD_S)
	name = name or Deformat(message, ERR_ALREADY_INVITED_TO_GUILD_S)
	if name and self:GetPlayersDb()[name] then
		self:GetPlayersDb()[name].whispered = time() - _purgeThreshold + _halfDay
		return
	end

	-- Allow players who go offline before we can whisper or invite them to be invited again immediately.
	name = Deformat(message, ERR_CHAT_PLAYER_NOT_FOUND_S)
	name = name or Deformat(message, ERR_GUILD_PLAYER_NOT_FOUND_S)
	if name then
		self:GetQueriedDb()[name] = nil
		self:GetPlayersDb()[name] = nil
		return
	end

	-- Welcome new Zerglings to the guild.
	if self:IsWelcomingZerglings() then
		local name = Deformat(message, ERR_GUILD_JOIN_S)
		if name then
			if name ~= _player then
				-- Only send one welcome message if many Zerglings join within a rolling 10-second window (and they haven't left us).
				if _welcomeTimer then
					self:CancelTimer(_welcomeTimer, true)
					_welcomeTimer = nil
				end
				tinsert(_welcomeNames, name)

				-- Schedule a new message even if we failed to cancel an outstanding timer. Canceling might have failed because the timer had already been hit but _welcomeTimer had not been
				-- cleared yet.
				_welcomeTimer = self:ScheduleTimer("SendWelcome", 10)
			end
			return
		end
	end

	-- Catch all other system events which announce the results of /who queries. If the WhoFrame is visible however, any "who" query result appearing in the chat log would also show up in
	-- the WhoFrame (and will be processed by WHO_LIST_UPDATE), so we can then safely ignore them.
	if not WhoFrame:IsVisible() then
		-- Deformat extracts the guild name strangely. If the player is in a guild, the first part comes out as "Race Class <Guild Up To Last Space" whereas the second part comes out as
		-- "LastGuildNameWord>". If the player is in a single-word guild, guild1 will be "Race Class" and guild2 will be "<GuildName>". If the player is not in a guild, guild1 will be
		-- "Race" while guild2 will be "Class".
		-- In any case, we need to append those with an empty space in-between to get the full picture and extract the actual guild name.
		local name, _, level, guild1, guild2 = Deformat(message, WHO_LIST_FORMAT)
		if name then
			-- Go through the process even if that player is in a guild, so as to mark them as queried already.
			self:ScheduleWhisperSalesPitch(name, "manual", strmatch(format("%s %s", guild1, guild2), "<(.-)>"), level)
		end
	end
end

function TbAssimilator:SendWelcome()
    _welcomeTimer = nil

    -- The option might have been disabled between the time a Zergling was hatched and the time we would welcome them.
	if self:IsWelcomingZerglings() then
        -- Check whether any new Zergling may have quit the guild before we could welcome them.
        local count, welcomeName = 0
        for _, name in ipairs(_welcomeNames) do
            if TbCore:IsOnlineGuildMate(name) then
                count = count + 1
				welcomeName = name
                if count > 1 then break end
            end
        end

        -- Actually send the welcome message.
        if count == 1 then
            ChatThrottleLib:SendChatMessage("BULK", _addonName, format(L["WELCOME_SINGLE"], GetGuildInfo("player"), welcomeName), "GUILD")
        elseif count > 1 then
            ChatThrottleLib:SendChatMessage("BULK", _addonName, format(L["WELCOME_MULTIPLE"], GetGuildInfo("player")), "GUILD")
        end
	end
    wipe(_welcomeNames)
end

-- The specified player must be of the same faction.
function TbAssimilator:ScheduleWhisperSalesPitch(name, realm, guild, level)
    -- Provide defaults for the optional parameters.
    name, realm, guild, level = name or "", realm or GetRealmName(), guild or "", tonumber(level) or 1

    -- Only whisper other players on the same realm.
	local isForced, isManual = realm == "forced", realm == "manual"
    if not isForced and not isManual and realm ~= GetRealmName() then return end

    -- Check whether this player is eligible for a guild invitation.
	if not isForced and not self:ShouldInvite(name) then return end

    -- Prevent the specified player from being the subject of a /who query since we've already gathered information about them.
    self:GetQueriedDb()[name] = time()

	-- A "manual" request is considered "forced" for the purpose of displaying system messages. However, certain manual queries may still produce guilded players. The only time isForced is
	-- true is through the /tba <name> command.
	if isForced or guild == "" then
		-- Build the sales pitch to send.
		local isGuildApplicant, isAutomated= self:IsGuildApplicant(name), not (isForced or isManual)
		salesPitch = (isGuildApplicant and format(L["ACCEPTED_APPLICATION"], GetGuildInfo("player"))) or self:GetCustomSalesPitch(level, true)
		if name == _player then
			-- Send the whisper to ourselves for testing purposes.
			ChatThrottleLib:SendChatMessage("BULK", _addonName, salesPitch, "WHISPER", nil, name)
        elseif UnitIsInMyGuild(name) or salesPitch == "" then
			-- If name refers to a guild mate (only possible through a forced /tba command), don't save any data to avoid messing it up. Just send the guild invitation in order to produce
			-- an error. This will not cause a call to SavePlayer().
			-- When using an empty sales pitch, no whisper will be sent out, so send the invitation immediately. This will cause a call to SavePlayer().
			GuildInvite(name, isAutomated)
		elseif isAutomated then
			-- Put automated invitations on a queue to stagger them and avoid protect against suspensions.
			_whisperQueue:Enqueue({ name = name, salesPitch = salesPitch })
		else
			-- Send out manual and forced invitations immediately.
			self:WhisperSalesPitch(name, salesPitch)
		end
	end
end

function TbAssimilator:OnWhisperTimer()
	-- TODO: Occasionally, _db.factionrealm[GetGuildInfo("player")].something produces the error "attempt to index field '?' (a nil value)". I really can't figure this out, other than
	-- sometimes GetGuildInfo() returns nil even for the player? This has been reported in various places. Meanwhile, the nil check on guild should avoid such errors.
	local t, guild, info = time(), GetGuildInfo("player"), nil
	if not guild or not self:ShouldInvite() then
		self:ScheduleTimer("OnWhisperTimer", 1)
	else
		-- Make sure no single error can completely kill our heart beat.
		local success, ex = pcall(
			function()
				-- Get the oldest name from the queue. Note that due to the delay induced by our throttling, they may now be guilded.
				repeat
					info = _whisperQueue:Dequeue()
				until not info or TbAssimilator:ShouldInvite(info.name)
				if not info then
					-- Decrement the number of consecutive whispers we sent down to 0, since we failed to whisper anybody. Then check again in a second.
					_db.factionrealm[guild].numWhisperWindowConsecutive = max(0, _db.factionrealm[guild].numWhisperWindowConsecutive - 1)
					TbAssimilator:ScheduleTimer("OnWhisperTimer", 1)
				else
					-- Invite that player to the guild.
					TbAssimilator:WhisperSalesPitch(info.name, info.salesPitch, true)
					local whisperWindowRemaining, normalDelay = _whisperWindowMaxLength - t + _db.factionrealm[guild].whisperWindowStart, function() return min(15, math.pow(2, _db.factionrealm[guild].numWhisperWindowConsecutive - 1)) end
					if whisperWindowRemaining <= 0 then
						-- Mark the start of a new whisper window.
						_db.factionrealm[guild].whisperWindowStart, _db.factionrealm[guild].numWhisperWindowWhispered, _db.factionrealm[guild].numWhisperWindowConsecutive = t, 1, 1
						TbAssimilator:ScheduleTimer("OnWhisperTimer", normalDelay())
					else
						-- Stagger invitations exponentially the more we send, but do not invite more than 6 players within a single whisper window.
						_db.factionrealm[guild].numWhisperWindowWhispered, _db.factionrealm[guild].numWhisperWindowConsecutive = _db.factionrealm[guild].numWhisperWindowWhispered + 1, _db.factionrealm[guild].numWhisperWindowConsecutive + 1
						if _db.factionrealm[guild].numWhisperWindowWhispered > 5 then
							TbAssimilator:ScheduleTimer("OnWhisperTimer", max(whisperWindowRemaining, normalDelay()))
						else
							TbAssimilator:ScheduleTimer("OnWhisperTimer", normalDelay())
						end
					end
				end
			end)
		if not success then
			self:ScheduleTimer("OnWhisperTimer", 1)
			self:PrintEmphasis("%s: 'OnWhisperTimer' successfully recovered from an error [%s]%s.", _addonTitle, ex, (info and format(" trying to whisper [%s]", info.name)) or "")
		end
	end
end

function TbAssimilator:WhisperSalesPitch(name, salesPitch, isAutomated)
	-- Initialize this player's data in our internal database, without immediately writing .whispered. The only way the player is already in the database is if we are forcing this.
	-- Otherwise, we would not reach this point.
	self:SavePlayer(name, not isAutomated)

	-- Whisper that player our sales pitch. When the whisper actually goes out, we will follow up with a guild invitation. This is to work around all chat throttling, both from the server
	-- and ChatThrottleLib.
	ChatThrottleLib:SendChatMessage("NORMAL", _addonName, salesPitch, "WHISPER", nil, name)
end

function TbAssimilator:GuildInvite(name, isAutomated)
	if not UnitIsInMyGuild(name) then
		-- Capitalize names coming from the command line.
		name = TbCore:Capitalize(self:Trim(name))

		-- Print a warning if that player is blacklisted. This will only happen on a forced invitation. Manual and automated invitations would be have been blocked by the ShouldInvite()
		-- check in WhisperSalesPitch().
		if self:IsBlackListed(name) then
			self:PrintEmphasis(L["INVITING_BLACKLISTED"], _addonTitle, name)
		end

		-- Don't save guildmates we inadvertently /ginvite, since that could cause guild notes to get messed up.
        -- If we come in here through the automated process, isAutomated may actually be false, meaning isForced may be true (/tba <name> would trigger a call to
		-- ScheduleWhisperSalesPitch() and we would then end up here). However, in those cases the player's entry would already be initialized, so SavePlayer() would not be invoked.
        self:SavePlayer(name, not isAutomated, true)

		-- Mark that we've invited this player. This will be useful if they accept the guild invitation, in which case we will need to promote them and set a clue in their guild notes.
		self:GetPlayersDb()[name].invited = time()
	end

    -- Do the actual inviting.
	return self.hooks.GuildInvite(name)
end

-- UnitIsInMyGuild() should be checked before coming in here.
function TbAssimilator:SavePlayer(name, forced, writeWhispered)
	-- 1) When invoked on a guild mate from ScheduleWhisperSalesPitch(), we will not get here due to the UnitIsInMyGuild() check in GuildInvite().
	-- 2) When invoked with an empty sales pitch from ScheduleWhisperSalesPitch(), .whispered will be written because GuildInvite() passes true as the value of writeWhispered.
	-- 3) When otherwise invoked from ScheduleWhisperSalesPitch(), we are either forced through /tba (or a directed /who), or the player does not exist in our database (if the player
	-- 	  existed in the database the automatic process would have aborted already). Because we are invoked directly with writeWhispered being nil, .whispered is not written.
	-- 4) When invoked from CHAT_MSG_WHISPER_INFORM() through GuildInvite(), either it was the automated process and we will abort early without overwriting any data (because the player is
	-- 	  already in the database due to the earlier call from ScheduleWhisperSalesPitch()); or it was a forced command, in which case we will overwrite the player's data. We should be
	-- 	  careful to preserve the .isForced and .whispered fields in such cases. Since we would come in here through GuildInvite(), .isForced will be re-assigned correctly, and
	-- 	  writeWhispered will be true, causing .whispered to be read out and re-written.
	-- 5) When invoked through a direct call to GuildInvite() (either from the command line, or from a whispered guild invitation request), forced and writeWhispered will both be true and
	--	  we will preserve .whispered.
	if not self:GetPlayersDb()[name] or forced then
        local t, whispered = time()
        self:GetQueriedDb()[name] = t
        if self:GetPlayersDb()[name] then
			whispered = self:GetPlayersDb()[name].whispered
            wipe(self:GetPlayersDb()[name])
        else
            self:GetPlayersDb()[name] = { }
        end

		if writeWhispered then
			self:GetPlayersDb()[name].whispered = whispered or t
		end
        self:GetPlayersDb()[name].isForced = forced or nil
    end
end

function TbAssimilator:GUILD_ROSTER_UPDATE(e, updateNeeded)
    if updateNeeded then
		-- Request an updated roster after player joins or leaves the guild.
        GuildRoster()
    else
		-- Only purge our players database the first time we receive our guild roster, since it relies on UnitIsInMyGuild().
		self:PurgePlayersDb()

		-- Fix the Zerglings' data and guild ranks.
		self:FixZerglings()
    end
end

function TbAssimilator:IGNORELIST_UPDATE()
	local numIgnores = GetNumIgnores()
	if numIgnores > _numIgnores then
		-- Check if anybody we are ignoring snuck into the guild.
		local blackListedGuildMates = { }
		for i = 1, numIgnores do
			local name = GetIgnoreName(i)
			if name ~= UNKNOWN and UnitIsInMyGuild(name) then
				tinsert(blackListedGuildMates, name)
			end
		end

		-- Do the same for blacklisted guild mates.
		for name in pairs(_db.factionrealm[GetGuildInfo("player")].blackList) do
			if UnitIsInMyGuild(name) then
				tinsert(blackListedGuildMates, name)
			end
		end

		-- Print a notice about our blacklisted guild mates.
		if #blackListedGuildMates > 0 then
			print(" ")
			print(format(L["BLACKLISTED_GUILDMATES"], _addonTitle, #blackListedGuildMates))
			sort(blackListedGuildMates)
			print(table.concat(blackListedGuildMates, ", ") .. ".")
			print(" ")
		end
	end
	_numIgnores = numIgnores
end

function TbAssimilator:LF_GUILD_RECRUITS_UPDATED()
	if not self:ShouldInvite() then return end

	-- Notify the player of new guild recruiting applications.
	local numGuildApplicants = GetNumGuildApplicants()
	if numGuildApplicants > _numGuildApplicants then
		-- Print a notification so the player is aware in case not everyone can be invited.
		self:PrintEmphasis(L["NEW_GUILD_APPLICATIONS_ANNOUNCEMENT"], _addonTitle)

		-- Send only a /who on each applicant rather than an invitation. If we sent an invitation, we would want to make it "manual", causing all system messages to be printed. However,
		-- since some of these applicants might be offline we could end up with a lot of system spam if there are lots of requests coming in frequently.
		-- Some applicants might also be already guilded, and browsing for a new guild with the /gf command. Whispering those people so they could drop guild would be a good thing, but that
		-- is best left to the player themselves, since we could easily get situations where the applicant gets the whisper, responds with a guild invitation request, then the player sends
		-- one, then has to check that the applicant is already guilded.
		-- TODO: Once I have implemented multiple queues for /who queries, query these applicants every time we enter this function (but only print the notice if the number increased).
		for i = numGuildApplicants, 1, -1 do
			local name = GetGuildApplicantInfo(i)
			if self:ShouldInvite(name, true) then
				self:GetQueriedDb()[name] = nil
				self:GetPlayersDb()[name] = nil
				self:Who(name, _whoOptions)
			end
		end
	end
	_numGuildApplicants = numGuildApplicants
end

function TbAssimilator:SlashCommand(arg)
	local guild, _, myRankIndex = GetGuildInfo("player")
	if not arg or arg == "" then
		ConfigDialog:Open(_addonName)
	elseif #arg > 1 then
		if not guild then return end

		-- Determine whether the user means to invite or blacklist this player.
		-- Note: if the player passed in "-", then minus will be blank and arg will be "-".
		local minus, arg = strmatch(arg, "(%-*)(.+)")
		if #arg > 1 then
			arg = TbCore:Capitalize(self:Trim(arg))
			if #minus == 0 then
				-- Send the sales pitch to the player with that name.
				self:ScheduleWhisperSalesPitch(arg, "forced")
			else
				-- Blacklist this player, even if they already were.
				_db.factionrealm[GetGuildInfo("player")].blackList[arg] = true
				self:AddKickNote(arg)
				print(format(L["BLACKLIST_SUCCESS"], _addonTitle, arg))
			end
		end
	elseif strmatch(arg, TbCore:GetCaseInsensitivePattern("c")) then
		InterfaceOptionsFrame_OpenToCategory(_addonName)
	elseif strmatch(arg, TbCore:GetCaseInsensitivePattern("s")) then
		for _, message in ipairs(self:GetStatsMessage()) do
			print(message)
		end
	elseif strmatch(arg, TbCore:GetCaseInsensitivePattern("f")) then
		-- Fix the Zerglings' data and guild ranks.
		self:FixZerglings(true)
	else
		-- By now we know the argument length is 1 and we are trying to purge the guild of its laziest Zerglings.
		if not guild then return end

		-- Abort if our rank is not higher than that of the Zerglings.
		if not CanGuildRemove() or myRankIndex >= self:GetAssimilationRankZeroBased() then
			print(format(L["CANNOT_SACRIFICE"], _addonTitle))
			return
		end

		-- Always rebuild the confirmation dialogs for the heavy-handed kick commands, to make sure we're using the latest assimilation rank name.
		if strmatch(arg, TbCore:GetCaseInsensitivePattern("l")) then
			self:SacrificeZerglings(0)
		elseif strmatch(arg, TbCore:GetCaseInsensitivePattern("m")) then
			StaticPopupDialogs["CONFIRM_TBA_M"] =
			{
				text = format(L["CONFIRM_TBA_M"], TbAssimilator:GetGuildRanks()[TbAssimilator:GetAssimilationRankZeroBased()]),
				button1 = YES,
				button2 = NO,
				OnAccept = function() TbAssimilator:SacrificeZerglings(1) end,
				timeout = 60,
				whileDead = true,
				hideOnEscape = true,
			}
			StaticPopup_Show("CONFIRM_TBA_M")
		elseif strmatch(arg, TbCore:GetCaseInsensitivePattern("k")) then
			StaticPopupDialogs["CONFIRM_TBA_K"] =
			{
				text = format(L["CONFIRM_TBA_K"], TbAssimilator:GetGuildRanks()[TbAssimilator:GetAssimilationRankZeroBased()]),
				button1 = YES,
				button2 = NO,
				OnAccept = function() TbAssimilator:SacrificeZerglings(2) end,
				timeout = 60,
				whileDead = true,
				hideOnEscape = true,
			}
			StaticPopup_Show("CONFIRM_TBA_K")
		elseif strmatch(arg, TbCore:GetCaseInsensitivePattern("a")) then
			StaticPopupDialogs["CONFIRM_TBA_A"] =
			{
				text = format(L["CONFIRM_TBA_A"], TbAssimilator:GetGuildRanks()[TbAssimilator:GetAssimilationRankZeroBased()]),
				button1 = YES,
				button2 = NO,
				OnAccept = function() TbAssimilator:SacrificeZerglings(3) end,
				timeout = 60,
				whileDead = true,
				hideOnEscape = true,
			}
			StaticPopup_Show("CONFIRM_TBA_A")
		end
	end
end

function TbAssimilator:FixZerglings(isForced)
	local guild, _, myRankIndex = GetGuildInfo("player")
	if not guild then return end

	-- Abort if another guild roster modification is underway.
	if _isProcessingGuildRoster then return end
	_isProcessingGuildRoster = true

	-- Only process the guild roster if we gained members since last time.
	local numGuildMates = GetNumGuildMembers()
	if isForced or numGuildMates > _numGuildMates then
		-- Make sure and include offline guild members.
		local t, wasShowingOffline, assimilationRank = time(), GetGuildRosterShowOffline(), self:GetAssimilationRankZeroBased()
		SetGuildRosterShowOffline(true)
		for i = numGuildMates, 1, -1 do
			local name, _, rankIndex, _, _, _, publicNote, officerNote = GetGuildRosterInfo(i)
			if rankIndex >= assimilationRank then
				-- Add the assimilation date if it is missing.
				self:FixZerglingData(i, name, t, publicNote, officerNote)

				-- Promote the player if needed, and if we can.
				self:PromoteZergling(myRankIndex, i, name, rankIndex, assimilationRank, t)
			end
		end

		-- Restore our original roster state.
		SetGuildRosterShowOffline(wasShowingOffline)
	end
	_numGuildMates = numGuildMates
	_isProcessingGuildRoster = nil
end

function TbAssimilator:FixZerglingData(i, name, t, publicNote, officerNote)
	-- Increment our statistics counter if we assimilated this Zergling.
	-- Note: this may produce false positives against players who declined our guild invitation, but accepted another officer's.
	if self:GetPlayersDb()[name] and self:GetPlayersDb()[name].invited and not self:GetPlayersDb()[name].joined then
		_db.factionrealm[GetGuildInfo("player")].numAssimilated = _db.factionrealm[GetGuildInfo("player")].numAssimilated + 1
	end

	-- Initialize the player's data if missing.
	self:GetPlayersDb()[name] = self:GetPlayersDb()[name] or { }

	-- Reconcile our assimilation dates.
	local location, guildNote, canViewGuildNote, canEditGuildNote, setGuildNote, lead, trail, rewriteNote = self:GetTimeStampLocation(), publicNote, true, CanEditPublicNote(), GuildRosterSetPublicNote
	if location > 0 then
		-- Figure out which guild note to read, public or officer.
		if location > 1 then
			guildNote, canViewGuildNote, canEditGuildNote, setGuildNote = officerNote, CanViewOfficerNote(), CanEditOfficerNote(), GuildRosterSetOfficerNote
		end

		-- Public notes are viewable by anyone, but not officer notes.
		if canViewGuildNote then
			-- Try extracting a date entered as Y/M/D from the guild note.
			local assimilatedYear, assimilatedMonth, assimilatedDay, noteT
			lead, assimilatedYear, assimilatedMonth, assimilatedDay, trail = strmatch(guildNote, "^%s*(.-)%s*(%d+)[/-](%d+)[/-](%d+)%s*(.-)%s*$")

			-- Even if we managed to extract a date in that form, it may be invalid (eg. 9999999/41/355). Try to parse it into an actual time value. Note: future dates are invalid.
			-- Note: time { } defaults to noon for some idiotic reason.
			-- Also: if multiple assimilation dates are entered in the guild note, the first one to validate within the note is always used.
			if assimilatedYear and assimilatedMonth and assimilatedDay then
				noteT = time { year = assimilatedYear, month = assimilatedMonth, day = assimilatedDay, hour = 0, min = 0, sec = 0 }
			end

			-- Try extracting it in the form M/D if the first format failed.
			if not noteT or noteT > t then
				assimilatedYear, lead, assimilatedMonth, assimilatedDay, trail = date("%Y"), strmatch(guildNote, "^%s*(.-)%s*(%d+)[/-](%d+)%s*(.-)%s*$")
				if assimilatedMonth and assimilatedDay then
					noteT = time { year = assimilatedYear, month = assimilatedMonth, day = assimilatedDay, hour = 0, min = 0, sec = 0 }
				end

				-- Whether we were successful or not, we want to rewrite the guild note to standardize all assimilation date formats.
				rewriteNote = canEditGuildNote
			elseif #assimilatedYear < 4 or #assimilatedMonth < 2 or #assimilatedDay < 2 then
				-- We successfully parsed a Y/M/D date but it did not have the set numbers of digits for each date part. Rewrite the note to standardize it.
				rewriteNote = canEditGuildNote
			end

			-- Validate the Zergling's assimilation date again.
			if noteT and noteT <= t then
				-- We read a valid date. We only want to use it if our internal database is missing an assimilation date. One of several things may have happened:
				-- 1) The Zergling left and rejoined after their data was wiped (because they were in the guild for longer than 10 days). When they rejoin, their guild note will still contain
				-- 	  the correct time stamp, so we can use that to reconcile our internal database.
				-- 2) Another guild officer is running the add-on and time stamped this player. Since we did not have any data. We can use the guild note. Things may get iffy if the player when
				-- 	  multiple players are running the add-on, however, so it is not recommended or supported. If they are careful about their settings, things should turn out all right.
				-- 3) The Zergling left and rejoined before their data was wiped. Our internal database is still current.
				-- 4) The time stamp setting was switch from one guild note slot to another in-between. We do not handle any such cases at all, as the player receives ample warning that doing.
				-- 	  Thus make no effort to figure out if the other note has the data we need.
				if not self:GetPlayersDb()[name].joined then
					self:GetPlayersDb()[name].joined = noteT
				elseif self:GetPlayersDb()[name].joined < noteT or self:GetPlayersDb()[name].joined >= noteT + _day then
					-- The time stamp in the guild note does not represent the same day as our internal database. The internal database takes precedence, since it cannot be messed with by other
					-- guild officers.
					rewriteNote = canEditGuildNote
				end
			else
				-- We failed to read an assimilation date. We will need to rewrite the guild note with whatever data we have instead.
				rewriteNote = canEditGuildNote

				-- Since we failed to read an assimilation date from the guild note, lead and trail would both be nil. We will want to append the full existing note to the end of the new
				-- assimilation date. This ensures that next time we read the note, we find a valid assimiliation date (unless it is modified).
				lead, trail = "", self:Trim(guildNote)
			end
		end
	end

	-- Overwrite our internal assimilation date if it was previously missing and any of the following is true:
	-- 1) We are not using guild notes to time stamp assimilation dates.
	-- 2) A player joins the guild for the first time (their guild note has no assimilation date).
	-- 3) A player had both their guild notes wiped before leaving the guild, and rejoins after their internal data was wiped too (no reconciliation is possible from the guild notes).
	-- 4) A player left the guild, the time stamp location was switching from one guild note slot to the other, and then they rejoined. We generally don't support situations involving
	-- 	  switching between time stamp locations.
	self:GetPlayersDb()[name].joined = self:GetPlayersDb()[name].joined or t

	-- Update the guild note if needed. We won't enter if we lack permission to both view and edit the public note.
	if rewriteNote then
		-- The - 1 is for the space between the leading string and the full date.
		local fullDate = date("%Y/%m/%d", self:GetPlayersDb()[name].joined)
		setGuildNote(i, self:Trim(format("%s %s %s", self:Trim(lead, 31 - 1 - #fullDate), fullDate, trail)))
	end

	-- Fill in any other missing data, and add the whispered date, because it is always assumed to exist if _db.factionrealm[guild].players[name] exists, throughout the add-on.
	self:GetPlayersDb()[name].invited = self:GetPlayersDb()[name].invited or (self:GetPlayersDb()[name].whispered and self:GetPlayersDb()[name].whispered + _inviteDelay) or self:GetPlayersDb()[name].joined - _inviteDelay
	self:GetPlayersDb()[name].whispered = self:GetPlayersDb()[name].whispered or self:GetPlayersDb()[name].invited - _inviteDelay
end

function TbAssimilator:PromoteZergling(myRankIndexZeroBased, i, name, rankIndexZeroBased, assimilationRankZeroBased, t)
	if CanGuildPromote() and myRankIndexZeroBased < rankIndexZeroBased and rankIndexZeroBased > assimilationRankZeroBased and not self:GetPlayersDb()[name].promoted then
		-- Only promote any given player once. If they are demoted later, it is with good reason, so keep them there.
		self:GetPlayersDb()[name].promoted = t

		-- The rank index returned by GetGuildRosterInfo() is 0-based whereas the rank index expected by SetGuildMemberRank() is 1-based.
		SetGuildMemberRank(i, assimilationRankZeroBased + 1)
	elseif rankIndexZeroBased == assimilationRankZeroBased then
		-- Remember that this member was already promoted before.
		self:GetPlayersDb()[name].promoted = self:GetPlayersDb()[name].promoted or t
	end
end

-- Always trim and capitalize name before calling AddKickNote().
function TbAssimilator:AddKickNote(name)
	local kickNote = self:GetKickNote()
	if not UnitIsInMyGuild(name) or not CanViewOfficerNote() or not CanEditOfficerNote() or #kickNote == 0 then return end

	local numGuildMates, wasShowingOffline = GetNumGuildMembers(), GetGuildRosterShowOffline()
	SetGuildRosterShowOffline(true)
	for i = numGuildMates, 1, -1 do
		local rosterName, _, _, _, _, _, _, rosterOfficerNote = GetGuildRosterInfo(i)
		if rosterName == name then
			-- Figure out if this Zergling already has a kick note.
			if not strmatch(rosterOfficerNote, kickNote) then
				-- Append the kick note, trimming the existing note to fit the kick note. The - 1 is for the space between the existing officer note and the kick note.
				GuildRosterSetOfficerNote(i, format("%s %s", self:Trim(rosterOfficerNote, 31 - 1 - #kickNote), kickNote))
			end
			break
		end
	end

	-- Restore the roster to its original state.
	SetGuildRosterShowOffline(wasShowingOffline)
end

-- rank and officerNote are always provided during a purge.
function TbAssimilator:GuildUninvite(name, rank, officerNote)
	local guild, _, myRank = GetGuildInfo("player")
	if guild and CanGuildRemove() and UnitIsInMyGuild(name) then
		if not rank or not officerNote then
			-- Capitalize names coming from the command line.
			name = TbCore:Capitalize(self:Trim(name))

			-- Read the sacrificed Zergling's missing rank and officer note.
			local numGuildMates, wasShowingOffline = GetNumGuildMembers(), GetGuildRosterShowOffline()
			SetGuildRosterShowOffline(true)
			for i = numGuildMates, 1, -1 do
				local rosterName, _, rosterRank, _, _, _, _, rosterOfficerNote = GetGuildRosterInfo(i)
				if rosterName == name then
					rank = rosterRank
					officerNote = rosterOfficerNote
					break
				end
			end

			-- Restore the roster to its original state.
			SetGuildRosterShowOffline(wasShowingOffline)
		end

		if myRank < rank then
			-- Increment our kick counter if it looks like we might be successful.
			_db.factionrealm[guild].numKicked = _db.factionrealm[guild].numKicked + 1

			-- If the sacrificed Zergling has the kick note in their officer note, blacklist them too.
			if #self:GetKickNote() > 0 and strmatch(officerNote, self:GetKickNote()) then
				_db.factionrealm[guild].blackList[name] = true
			end
		end
	end

	return self.hooks.GuildUninvite(name)
end

function TbAssimilator:SacrificeZerglings(purgeLevel)
	-- Purge the guild roster of inactive members.
	-- purgeLevel 0 = l
	-- purgeLevel 1 = m
	-- purgeLevel 2 = k
	-- purgeLevel 3 = a
	if _isProcessingGuildRoster then return end
	_isProcessingGuildRoster = true

	-- Create our temporary storage and make sure to include offline guild members.
	local toKick, sortedToKick, numGuildMates = { }, { }, GetNumGuildMembers()
	local wasShowingOffline, t, assimilationRankZeroBased, numKickTarget = GetGuildRosterShowOffline(), time(), self:GetAssimilationRankZeroBased(), floor(numGuildMates - self:GetNumMembersTarget() * (1 - .15 * max(1, purgeLevel)))
	SetGuildRosterShowOffline(true)
	for i = numGuildMates, 1, -1 do
		-- Only sacrifice enough guild members to reach the target number of guild members. We should check at the start of the loop because numKickTarget might be negative if we are
		-- below our target already.
		if #sortedToKick >= numKickTarget then break end
		local name, _, rankIndex, level, _, _, publicNote, officerNote, isOnline, _, class = GetGuildRosterInfo(i)
		if rankIndex >= assimilationRankZeroBased then
			local kick = purgeLevel > 2
			if not kick then
				-- Kick players marked with the kick note.
				kick = kick or (purgeLevel > 1 and officerNote and #self:GetKickNote() > 0 and strmatch(officerNote, self:GetKickNote()))
				if not kick and not isOnline and not (officerNote and #self:GetKeepNote() > 0 and strmatch(officerNote, self:GetKeepNote())) then
					-- Kick Zerglings who have been offline too long. The coefficients don't really matter as long as we use 29 days as a cutoff rather than 30 throughout this function.
					local _, totalXp = GetGuildRosterContribution(i)
					local lastOnlineYears, lastOnlineMonths, lastOnlineDays = GetGuildRosterLastOnline(i)
					lastOnlineDays = lastOnlineYears * 365 + lastOnlineMonths * 30 + lastOnlineDays
					if purgeLevel > 1 then
						kick = kick or (lastOnlineDays >  6 and totalXp < 20000)
						kick = kick or  lastOnlineDays >  9
					end
					if purgeLevel > 0 then
						kick = kick or (lastOnlineDays > 13 and totalXp < 60000)
						kick = kick or  lastOnlineDays > 20
					end
					kick =	   kick or  lastOnlineDays > 29

					if not kick then
						-- Add the assimilation date if it is missing, possibly reading it from a guild note.
						self:FixZerglingData(i, name, t, publicNote, officerNote)

						-- Calculate how many days they have been in the guild.
						local daysPresent = (t - self:GetPlayersDb()[name].joined) / _day
						if class == "DEATHKNIGHT" then
							if purgeLevel > 1 then
								if level == _maxLevel then
									kick = kick or (daysPresent > 69 and                	   totalXp < 10000000)
								end
								kick =     kick or (daysPresent > 59 and (level < _maxLevel or totalXp <  5000000))
								kick =     kick or (daysPresent > 29 and (level < 72 		or totalXp <   450000))
								kick =     kick or (daysPresent > 20 and (level < 68 		or totalXp <   150000))
							end
							if purgeLevel > 0 then
								kick =     kick or (daysPresent > 13 and (level < 64 		or totalXp <    50000))
								kick =     kick or (daysPresent >  6 and (level < 60 		or totalXp <    15000))
							end
							kick =         kick or (daysPresent >  3 and (level < 56 		or totalXp <     1500))
						else
							if purgeLevel > 1 then
								if level == _maxLevel then
									kick = kick or (daysPresent > 99 and                	   totalXp < 10000000)
								end
								kick =     kick or (daysPresent > 89 and (level < _maxLevel or totalXp <  5000000))
								kick =     kick or (daysPresent > 59 and (level < 65 		or totalXp <   450000))
								kick =     kick or (daysPresent > 29 and (level < 33 		or totalXp <    50000))
							end
							if purgeLevel > 0 then
								kick =     kick or (daysPresent > 20 and (level < 24 		or totalXp <    15000))
								kick =     kick or (daysPresent > 13 and (level < 17 		or totalXp <     5000))
								kick =     kick or (daysPresent >  6 and (level < 10 		or totalXp <     1500))
							end
							kick =         kick or (daysPresent >  4 and (level <  6 		or totalXp <      600))
							kick =         kick or (daysPresent >  2 and (level <  3 		or totalXp <      100))
						end
					end
				end
			end

			-- Mark that name for a later guild removal. Sometimes due to shifting indices we might get the same name multiple times. Ignore such cases.
			if kick and name ~= _player and not toKick[name] then
				toKick[name] = officerNote
				tinsert(sortedToKick, name)
			end
		end
	end

	-- Restore our original state now to minimize the guild roster updates we will need to process.
	SetGuildRosterShowOffline(wasShowingOffline)

	-- Start the actual kicking.
	for name, officerNote in pairs(toKick) do
		GuildUninvite(name, assimilationRankZeroBased, officerNote)
	end

	-- Announce the players we kicked to local chat.
	if #sortedToKick == 0 then
		print(format(L["SACRIFICED_NONE"], _addonTitle))
	else
		print(format(L["SACRIFICED"], _addonTitle, #sortedToKick))
		sort(sortedToKick)
		print(table.concat(sortedToKick, ", ") .. ".")
	end

	-- Displaying all the system messages will take a small amount of time proportional to the number of players being kicked (the function may need to be exponential from experience).
	self:ScheduleTimer(function()
						_numGuildMates = 0
						_isProcessingGuildRoster = nil
					   end, ceil(#sortedToKick / 10) * .15)
end

-- Always trim and capitalize name before calling ShouldInvite().
function TbAssimilator:ShouldInvite(name, isRequest)
	-- The add-on may be globally disabled through the configuration options.
	if not self:IsEnabled() or (GetNumGuildMembers() >= self:GetNumMembersTarget() and not isRequest) then return end

	-- Check the player itself first, if provided.
	if name and (#name < 2 or self:IsBlackListed(name) or UnitIsInMyGuild(name) or self:GetPlayersDb()[name] or name == _player or name == UNKNOWN or strmatch(name, "%-") or _whisperQueue:IsQueued(name)) then return end

	-- Pause the automated search while inside certain instances.
	if not isRequest then
		local isInInstance, instanceKind = IsInInstance()
		if isInInstance then
			if IsRatedMap() then return end
			if instanceKind == "raid" then
				-- Only pause if the raid is not trivial.
				local isTwentyFive, numRaidMembers = GetCurrentRaidDifficulty() % 2 == 0, GetNumRaidMembers()
				if isTwentyFive then
					return numRaidMembers < 21
				end
				return numRaidMembers < 9
			end
		end
	end
	return true
end

function TbAssimilator:IsGuildApplicant(name)
	for i = GetNumGuildApplicants(), 1, -1 do
		if GetGuildApplicantInfo(i) == name then
			return true
		end
	end
end

-- Always trim and capitalize name before calling IsBlackListed().
function TbAssimilator:IsBlackListed(name)
	return IsIgnored(name) or _db.factionrealm[GetGuildInfo("player")].blackList[name]
end

local _guildRanks = { }	-- Just to avoid making a new one each time; use the return value outside of this function!
function TbAssimilator:GetGuildRanks()
	-- Read our guild ranks into our options table. These are one-based (+1), but we need to start at the rank directly under ours (+1 again) since we can only promote up to that one.
	local lastRankOneBased, _, _, myRankIndexZeroBased = GuildControlGetNumRanks(), GetGuildInfo("player")
	wipe(_guildRanks)
	for i = myRankIndexZeroBased + 2, lastRankOneBased - 1 do
		if not self:RequiresAuthenticator(i) then
			_guildRanks[i - 1] = GuildControlGetRankName(i)
		end
	end

	-- Always add the last rank.
	_guildRanks[lastRankOneBased - 1] = GuildControlGetRankName(lastRankOneBased)
	return _guildRanks
end

function TbAssimilator:RequiresAuthenticator(rankOneBased)
	GuildControlSetRank(rankOneBased)
	return select(18, GuildControlGetRankFlags())
end

local _timeStampLocations = { }	-- Just to avoid making a new one each time; use the return value outside of this function!
function TbAssimilator:GetTimeStampLocations()
	-- Always add "None".
	wipe(_timeStampLocations)
	_timeStampLocations[0] = L["MENU_TIME_STAMP_NONE"]

	if GetGuildInfo("player") then
		if CanEditPublicNote() then
			_timeStampLocations[1] = L["MENU_TIME_STAMP_PUBLIC_NOTE"]
		end
		if CanViewOfficerNote() and CanEditOfficerNote() then
			_timeStampLocations[2] = L["MENU_TIME_STAMP_OFFICER_NOTE"]
		end
	end
	return _timeStampLocations
end

function TbAssimilator:GetStatsMessage()
	local statsMessages, guild = { }, GetGuildInfo("player")
	if not guild then
		tinsert(statsMessages, L["STATS_UNGUILDED"])
	elseif _db.factionrealm[guild].numAssimilated < 2 then
		tinsert(statsMessages, format(L["STATS_NONE"], _addonTitle))
	else
		local numAssimilated, numKicked, firstRun, catchPhrase = _db.factionrealm[guild].numAssimilated, _db.factionrealm[guild].numKicked, date("%Y/%m/%d", _db.factionrealm[guild].firstRun), _catchPhrases[random(1, #_catchPhrases)]
		if numKicked < 1 then
			tinsert(statsMessages, format(L["STATS_NO_SACRIFICES"], _addonTitle, numAssimilated, firstRun, catchPhrase))
		else
			tinsert(statsMessages, format(L["STATS_SACRIFICES"], _addonTitle, numAssimilated, numKicked, firstRun, catchPhrase))
		end
		if not self:ShouldInvite() then
			tinsert(statsMessages, format(L["PAUSED"], _addonTitle))
		end
	end
	return statsMessages
end

function TbAssimilator:GetQueriedDb()
	if not GetGuildInfo("player") then
		-- Simpler than making the caller check for nil.
		return { }
	end
	return _db.factionrealm[GetGuildInfo("player")].queried
end

function TbAssimilator:GetPlayersDb()
	if not GetGuildInfo("player") then
		-- Simpler than making the caller check for nil.
		return { }
	end
	return _db.factionrealm[GetGuildInfo("player")].players
end

function TbAssimilator:UpdateCustomSalesPitchPattern()
	_pitchPatternCustom = "^" .. gsub(gsub(gsub(self:GetCustomSalesPitch(), _regExCharsPattern, "%%%1"), "%%%%d", "%%d+"), _sPattern, _drunkenSPattern)
end

function TbAssimilator:GetPerks(level)
	-- Figure out which perks to advertise.
	local guildLevel, playerKind = GetGuildLevel(), "leveling"
	if level == _maxLevel then
		playerKind = "maxLevel"
	end

	-- Convert the guild perk spell IDs into actual strings if necessary.
	if type(_perks[guildLevel][playerKind][1]) == "number" then
		for i, perk in ipairs(_perks[guildLevel][playerKind]) do
			-- TODO: Grasping at straws here, but it seems sometimes the perks table does not get populated correctly? Try to get the link again if it failed?
			_perks[guildLevel][playerKind][i] = GetSpellLink(perk) or GetSpellLink(perk)
		end
	end

	-- Concatenate the perks into a comma-separated string.
	-- TODO: Memoize this.
	return table.concat(_perks[guildLevel][playerKind], ", ")
end

function TbAssimilator:CannotUseAddOn()
	return not (GetGuildInfo("player") and CanGuildInvite())
end

function TbAssimilator:CannotGuildTalk()
	local guild, _, myRankZeroBased = GetGuildInfo("player")
	if not guild then
		return true
	end

	GuildControlSetRank(myRankZeroBased + 1)
	local _, canGuildTalk = GuildControlGetRankFlags()
	return not canGuildTalk
end

function TbAssimilator:IsEnabled()
	return not self:CannotUseAddOn() and CanGuildInvite() and _db.factionrealm[GetGuildInfo("player")].isEnabled
end

function TbAssimilator:ToggleEnabled()
	local guild = GetGuildInfo("player")
	_db.factionrealm[guild].isEnabled = not _db.factionrealm[guild].isEnabled

	-- Time stamp our first run.
	if _db.factionrealm[guild].isEnabled then
		_db.factionrealm[guild].firstRun = _db.factionrealm[guild].firstRun or time()
	end
end

function TbAssimilator:IsUsingCustomSalesPitch()
	return GetGuildInfo("player") and _db.factionrealm[GetGuildInfo("player")].useCustomSalesPitch
end

function TbAssimilator:ToggleUseCustomSalesPitch()
	local guild = GetGuildInfo("player")
	if _db.factionrealm[guild].useCustomSalesPitch then
		_db.factionrealm[guild].useCustomSalesPitch = false
	else
		-- Pause the add-on if we are switching to a custom message.
		_db.factionrealm[guild].isEnabled = false

		-- If we are switching to using a custom sales pitch that is empty (eg. if switching to it for the first time, but not necessarily), pre-fill it with the default sales pitch.
		-- Note: the order of these lines here is very important. Do not change it!
		if _db.factionrealm[guild].customSalesPitch == "" then
			_db.factionrealm[guild].customSalesPitch = self:GetCustomSalesPitch()
		end
		_db.factionrealm[guild].useCustomSalesPitch = true
		self:UpdateCustomSalesPitchPattern()
	end
end

function TbAssimilator:GetCustomSalesPitch(level, addGuildLevel)
	if not GetGuildInfo("player") then return "" end

	if self:IsUsingCustomSalesPitch() then
		if addGuildLevel then
			-- Sub in the guild level. We want to return %d in the configuration panel and when building the matching pattern. The custom sales pitch is already trimmed by the set.
			return gsub(_db.factionrealm[GetGuildInfo("player")].customSalesPitch, "%%d", GetGuildLevel())
		end

		return _db.factionrealm[GetGuildInfo("player")].customSalesPitch
	end

	-- Send the whisper.
	local guildLevel, salesPitchFormat = GetGuildLevel(), L["DEFAULT_SALES_PITCH"]
	if guildLevel == 1 then
		salesPitchFormat = L["DEFAULT_SALES_PITCH_LOW"]
	end

	-- Be sure to trim the string so it's immediately obvious if something is wrong.
	return self:Trim(format(salesPitchFormat, GetGuildInfo("player"), guildLevel, self:GetPerks(level)), 255, true)
end

-- TODO: Replace with a call to TbCore:StrTrim?
function TbAssimilator:SetCustomSalesPitch(info, value)
	-- Trim the end of the string, and truncate the resultant if it is longer than the maximum length of a chat message. Truncate shorter than 255 to allow some room for drunkenness.
	value = self:Trim(value, 255, true)
	_db.factionrealm[GetGuildInfo("player")].customSalesPitch = value

	-- Update the detection pattern.
	self:UpdateCustomSalesPitchPattern()

	-- Print a test message before returning the new sales pitch.
	if strmatch(value, "%%d") then
		print(format(L["SALES_PITCH_TEST_ANNOUNCEMENT"], _addonTitle))
		self:SlashCommand(_player)
	end
	return value
end

function TbAssimilator:GetNumMembersTarget()
	return GetGuildInfo("player") and _db.factionrealm[GetGuildInfo("player")].numMembersTarget
end

function TbAssimilator:SetNumMembersTarget(info, value)
	_db.factionrealm[GetGuildInfo("player")].numMembersTarget = value
end

function TbAssimilator:GetAssimilationRankZeroBased()
	if not GetGuildInfo("player") then return end

	-- Adjust the assimilation rank if it has fallen outside the current range of guild ranks, or if it now requires an authenticator.
	local lastRankZeroBased = GuildControlGetNumRanks() - 1
	if _db.factionrealm[GetGuildInfo("player")].assimilationRankZeroBased > lastRankZeroBased or self:RequiresAuthenticator(_db.factionrealm[GetGuildInfo("player")].assimilationRankZeroBased + 1) then
		_db.factionrealm[GetGuildInfo("player")].assimilationRankZeroBased = lastRankZeroBased
	end
	return _db.factionrealm[GetGuildInfo("player")].assimilationRankZeroBased
end

function TbAssimilator:SetAssimilationRankZeroBased(info, rank)
	-- Do not allow setting to an authenticator rank.
	if self:RequiresAuthenticator(rank + 1) then
		rank = GuildControlGetNumRanks() - 1
	end
	_db.factionrealm[GetGuildInfo("player")].assimilationRankZeroBased = rank
end

function TbAssimilator:GetTimeStampLocation()
	local location, guild = 0, GetGuildInfo("player")
	if guild then
		location = _db.factionrealm[guild].timeStampLocation
		if (location == 2 and (not CanViewOfficerNote() or not CanEditOfficerNote())) or (location == 1 and not CanEditPublicNote()) then
			location = 0
		end
	end

	return location
end

function TbAssimilator:SetTimeStampLocation(info, value)
	if value == 2 then
		if _db.factionrealm[GetGuildInfo("player")].timeStampLocation == 1 then
			-- Do not allow transitioning directly from public notes to officer notes because that will make a mess of things.
			value = 1
		elseif not CanViewOfficerNote() or not CanEditOfficerNote() then
			-- Downgrade the selection to "None" if the player lacks sufficient permissions. Do not downgrade to public notes for the same reason as above.
			value = 0
		end
	elseif value == 1 then
		if _db.factionrealm[GetGuildInfo("player")].timeStampLocation == 2 then
			value = 2
		elseif not CanEditPublicNote() then
			value = 0
		end
	end

	-- Save and return the effective value.
	_db.factionrealm[GetGuildInfo("player")].timeStampLocation = value
	return self:GetTimeStampLocation()
end

function TbAssimilator:IsWelcomingZerglings()
	return GetGuildInfo("player") and _db.factionrealm[GetGuildInfo("player")].welcomeZerglings and not self:CannotGuildTalk()
end

function TbAssimilator:ToggleWelcomeZerglings()
	_db.factionrealm[GetGuildInfo("player")].welcomeZerglings = not _db.factionrealm[GetGuildInfo("player")].welcomeZerglings
end

function TbAssimilator:GetKeepNote()
	return GetGuildInfo("player") and _db.factionrealm[GetGuildInfo("player")].keepNote
end

-- TODO: Replace with a call to TbCore:StrTrim?
function TbAssimilator:SetKeepNote(info, note)
	_db.factionrealm[GetGuildInfo("player")].keepNote = self:Trim(note, 31)
	return self:GetKeepNote()
end

function TbAssimilator:GetKickNote()
	return GetGuildInfo("player") and _db.factionrealm[GetGuildInfo("player")].kickNote
end

-- TODO: Replace with a call to TbCore:StrTrim?
function TbAssimilator:SetKickNote(info, note)
	_db.factionrealm[GetGuildInfo("player")].kickNote = self:Trim(note, 31)
	return self:GetKickNote()
end

function TbAssimilator:IsBlackListingSpammers()
	return GetGuildInfo("player") and _db.factionrealm[GetGuildInfo("player")].blackListSpammers
end

function TbAssimilator:ToggleBlackListSpammers()
	_db.factionrealm[GetGuildInfo("player")].blackListSpammers = not _db.factionrealm[GetGuildInfo("player")].blackListSpammers
end

-- TODO: Reinstate TB Core's StrJoin.
local _sortedBlackList = { }	-- Just to avoid making a new one each time.
function TbAssimilator:GetBlackList()
	if not GetGuildInfo("player") then return end

	-- Convert our black list from a hash table into an array that we can sort and convert into a string.
	wipe(_sortedBlackList)
	for name in pairs(_db.factionrealm[GetGuildInfo("player")].blackList) do
		tinsert(_sortedBlackList, name)
	end

	-- Sort our updated blacklist alphabetically and the proper string to the configuration window.
	sort(_sortedBlackList)
	return table.concat(_sortedBlackList, "\n")
end

function TbAssimilator:SetBlackList(info, value)
	-- Convert the new line-separated string into an array containing each individual name.
	local names = self:Split(value)

	-- Update our internal database with the new blacklist, removing any duplicates from the user's input.
	wipe(_db.factionrealm[GetGuildInfo("player")].blackList)
	for i = #names, 1, -1 do
		local name = TbCore:Capitalize(names[i])
		if _db.factionrealm[GetGuildInfo("player")].blackList[name] then
			tremove(names, i)
		else
			names[i] = name
		end
		_db.factionrealm[GetGuildInfo("player")].blackList[name] = true
		self:AddKickNote(name)
	end

	-- Sort our updated blacklist alphabetically and return a new string for the configuration window.
	sort(names)
	return table.concat(names, "\n")
end

function TbAssimilator:PrintEmphasis(messageFormat, ...)
	print(" ")
	print(format(messageFormat, ...))
	print(" ")
end

-- TODO: Move to TB Core.
function TbAssimilator:Trim(value, maxLength, supportDrunk)
	maxLength = maxLength or #value
	if supportDrunk then
		-- Count the number of 's's in the string in order to determine how much room we need to allow for drunkenness.
		local _, numS = gsub(value, _sPattern, "%1")
		maxLength = maxLength - numS
	end
	return strsub(gsub(value, "^%s*(.-)%s*$", "%1"), 1, maxLength)
end

-- TODO: Move to TB Core and memoize the pattern.
function TbAssimilator:Split(value)
	local splitElements = { }
	gsub(value, "([^\n,;:|%s]+)", function(element) tinsert(splitElements, self:Trim(element)) end)
	return splitElements
end

